/* codebase:BlueStraggler http://blu.strglr.net
 * toolset:SlDoc          http://blu.strglr.net/Sldoc
 * package:hardcoded
 * file:taglet_module.groovy
 * 
 * Copyright 2011, 2012 Stellabs.
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

/* IMPORTANT:
 * This has been tested with Snow Leopard and this uses various resources from 3rd parties.
 * This work is expected to operate with POSIX systems.
 * However access to some resources is currently hardcoded in this source file, so it is strongly recommended to
 * update them in the code BELOW before using SlDoc:
 * 
 * -class ImageConversionHelper: conversion2handlerM (absolute paths) [extension2typeM, type2dataM for handled formats]
 * 
 * On the bright side, these updates are straightforward.
 */

package net.strglr.blu.doctool;
import com.sun.tools.doclets.Taglet;
import com.sun.javadoc.*;
import java.text.SimpleDateFormat;
import org.codehaus.groovy.runtime.InvokerHelper;
import groovy.xml.MarkupBuilder;
import groovy.transform.Field;
import java.security.MessageDigest;

class Log {
  static final active = [debug:false, warning:false, info:false];
  def write = {println it};
  static final Log debug = active.debug?new Log():null;
  static final Log warning = active.warning?new Log():null;
  static final Log info = active.info?new Log():null;
}

class TagletException extends Exception {
    String reason;
    String resource;
    TagletException(String _reason, String _resource = null) {
        reason = _reason;
        resource = _resource
    }
    def String toString() {
        return reason + (resource ? ' [in: '+resource+']':'');
    }
};


@Category(Properties) 
class PropertyStuff {
    static final ClassLoader classloader = getClassLoader();

    // Load from a Java-style property file (lines like 'the.property.key=its value')
    def loadFromFile(filename) { def ego = this;  
        def propURL = classloader.getResource(filename);
        if (propURL) ego.load(new FileInputStream(new File(propURL.getFile())));
        else return null;
        return ego;
    }
};


@Category(Map)
class AttributeStuff {
    static def throwerror = { throw new TagletException(it); };

    // Store the contents of a tag in a property map (default values should be pre-stored)
    def parseAttributes(String sTag) { def ego = this;
        def attributes = ego;
        sTag = sTag.trim();
        def regexProp = /([a-zA-Z]+)="(.*?)"(?: +)(.*)/;
        def matcher = (sTag =~ regexProp);
        while (matcher.matches()) {
            attributes."${matcher[0][1]}" = matcher[0][2];
            sTag = matcher[0][3];
            matcher = (sTag =~ regexProp);
        }
        matcher = (sTag =~ /"(.+?)"/);
        if (matcher.matches()) attributes._filename = matcher[0][1];
        else return null; // In case of a problem: something doesn't match
        return attributes;
    }

    def filterDatasource() { def ego = this;
        def attributes = ego;
        if (!attributes) throwerror("invalid text entry");
        // File whose content is the data-source to process
        if (null != attributes.src && null != attributes._filename)
            throwerror("source files assigned twice");
        attributes.src = attributes.remove('_filename');
    }
};


@Category(String)
class DirectoryStuff {

    // Returns data if self (must be canonical) is within a path of abspathlist, null otherwise
    def dataFromPath(abspathlist) { def ego = this; // 'self', canonical dirname string
        def absdir = ego.toString();
        def pathindex = 0;
        for (path in abspathlist) {
            path = new File(path).getCanonicalPath();
            if (!absdir.indexOf(path)) {
                return [rootindex:pathindex, rootname:path, dirname:(absdir - path - '/')];
            }
            pathindex++;
        }
        return null;
    }

    // Make a relative path from the 'self' dir to the to-dir
    def relativize(String todir){ def ego = this; // 'self' dirname
        def fromdir = ego;
        def fr = fromdir.tokenize('/'); def to = todir.tokenize('/');
        int fsize = fr.size(); int tsize = to.size(); int samesize = 0;
        fr.eachWithIndex { fe, i ->
            if (i <= tsize && to[i] == fe) {samesize++; fr[i] = '.'; to[i] = '.'};
            else if (i >= samesize) {fr[i] = '..'};
        };
        return ((fr + to )-'.').join('/');
    }

};


@Category(Object)
class HtmlStuff {
    def escapePlaintext(){ def ego = this;
        return new MarkupBuilder().transformValue(ego.toString()).replace('\n','<br/>');
    }

    def importHtmltext() { def ego = this;
        return ego.toString();
    }

    def importHtmltext2(taghead) { def ego = this;
        def str = ego.toString();
        def inlineregex = /(.*)\{\@/+taghead+/((?:\.\w+)+)\s+(.*)\}(.*)/;
        def matcher = (str =~ inlineregex);
        if (matcher.matches())
            return [type:'inlinetag', before:matcher[0][1], tail:matcher[0][2], text:matcher[0][3], after:matcher[0][4]];
        else return [type:null, text:str];
    }
};


class ImageConversionHelper {
    // INFORMATION MAPS
    // extension(s) => type
    static def extension2typeM = [
        ['gv', 'dot']:'graphviz'
        , ['gml']:'gml'
        , ['gxl']:'gxl'
        , ['jpg', 'jpeg']:'jpeg'
        , ['svg']:'svg'
        , ['png']:'png'
    ];
    // type => (extension, default-target-type)
    static def type2dataM = [
        'graphviz':[ext:'gv', defto:'svg']
        , 'gml':[ext:'gml', defto:'svg']
        , 'gxl':[ext:'gxl', defto:'svg']
        , 'png':[ext:'png', defto:'png'] // Fake
        , 'svg':[ext:'svg']
    ];
    // (from:type, to:type) => command-line (bash)
    static def conversion2handlerM = [
        ['graphviz', 'svg']:[command:'/usr/local/bin/dot -o\${tgt} -Tsvg \${src}']
        , ['gml', 'svg']:[command:'/usr/local/bin/gml2gv \${src} | /usr/local/bin/dot -Tsvg -o\${tgt}']
        , ['gxl', 'svg']:[command:'/usr/local/bin/gxl2gv \${src} | /usr/local/bin/dot -Tsvg -o\${tgt}']
        , ['gml', 'png']:[command:'/usr/local/bin/gml2gv \${src} | /usr/local/bin/dot -Tpng -o\${tgt}']
        , ['graphviz', 'png']:[command:'/usr/local/bin/dot -o\${tgt} -Tpng \${src}']
        , ['gxl', 'png']:[command:'/usr/local/bin/gxl2gv \${src} | /usr/local/bin/dot -Tpng -o\${tgt}']
        , ['png', 'png']:[command:'cp \${src} \${tgt}']  // Fake conversion to include as-is :)
    ];

    // CLOSURES
    static def extension2type = { ext ->
        def res;
        extension2typeM.each{ exts, type ->  if (exts.contains(ext)) { res= type; return; } }
        return res;      };

    static def type2data = {t,d -> try {return type2dataM[t][d];} catch (e) {return null;}};

    static def conversion2handler  = { ctype, ttype ->
        def conv = [ctype, ttype];
        try {return conversion2handlerM[conv];} catch (e) {return null;} };
};


// !! The following is a Category but the code would throw if annotated with @Category(AbstractTaglet)
class FileDataHelper {
    static def throwerror = { throw new TagletException(it); };

    static def javasourceData(tlpc, SourcePosition srcpos) {
        def _tlp = tlpc; def _pos = {srcpos};
        def javasource = [:]; def _javasource = {javasource};
        _javasource().with {
            file = _pos().file();
            absparent = file.getParentFile().getCanonicalPath();
            abspathlist = _tlp('sourcecode.abspath').tokenize(':');
            parentdata = use(DirectoryStuff){ absparent.toString().dataFromPath(abspathlist); };
            if (null == parentdata)
                throwerror("invalid property: 'taglet.sourcecode.abspath'");
        }
        return javasource;
    }

    static def originData(tlpc, Map refsource, Map attributes) {
        def _tlp = tlpc; def _refsource = {refsource};
        def _attributes = {attributes};
        def origin = [:]; def _origin = {origin};
        // The designated filename may start from file-system root or doc-root (case '/...')
        // or be relative to package directory in the doc-root
        def name = "";
        if (attributes.src.startsWith('/')) {
            if ('true'==attributes.usedocroot.toString().toLowerCase()) {
                name = _tlp('handmade.doc.root'); } // use doc root
            // (otherwise that relates to the file-system root by default)
        }
        else if (_refsource().parentdata){ // relative to package directory; combine doc-root and java package-path
            name = _tlp('handmade.doc.root') + _refsource().parentdata.with{'/' + dirname + '/'};
        }
        else { // Should happen only with some indirect resources
            assert true == _refsource()?.haschildresource;
            name = _refsource().file.getParentFile().getCanonicalPath() +'/';
        }
        name += attributes.src;
        origin.file =  new File(name);
        if (!origin.file.exists()) throwerror("file doesn't exist");

        origin.with {
            filename = file.getName();
            absparent = file.getParentFile().getCanonicalPath();
            abspathlist =_tlp('handmade.doc.root').tokenize(':');
            parentdata = use(DirectoryStuff){ absparent.toString().dataFromPath(abspathlist); };
            lastmodified = file.lastModified();
            filedata = [:];
        }
        // Compute whether it is a '*sldoc*', i.e. file that shouldn't be copied
        def srcregex = /(.*)\.sldoc(\..*)?/
        def srcmatcher = (origin.filename =~ srcregex);
        origin.filedata.issldoc = (srcmatcher.matches());
        // Compute base-name, extension etc.
        def extregex = /([^.]*)(.*\.)?([^.]+)?/;
        def extmatcher = (origin.filename =~ extregex);
        if (extmatcher.matches()) origin.filedata.with {
            basename = extmatcher[0][1] ?: "";
            middlename = extmatcher[0][2] ?: "";
            extension = extmatcher[0][3] ?: "";
        }
        return origin;
    }
};

abstract class AbstractTaglet implements Taglet {
    static def tagPrefix = "sldoc";
    static def tlClassInfo;
    def globalrefs; // Global references that are accessible from any taglet

    static final void tlRegister(Map tlmap, reginfo) {
        RegistrationHelper.registerTaglet(tlmap, reginfo);
    }

    static final throwerror(reason, resource=null) {
        throw new TagletException(reason, resource);
    }

    static final Map runtimeProperties = use(PropertyStuff) {
        // Read the properties from a file in the taglet's classpath
        return new Properties().loadFromFile("taglet.properties"); }
    static final def tlp(prop) { return runtimeProperties["taglet.${prop}"];}

    String getName(){
        NAME;
    }
    boolean inField(){
        true;
    }
    boolean inConstructor(){
        true;
    }
    boolean inMethod(){
        true;
    }
    boolean inOverview(){
        true;
    }
    boolean inPackage(){
        true;
    }
    boolean inType(){
        true;
    }

    // By default will the toString applicable to each tag
    String toHtml(Tag[] tags){
        if (null == tags || 0 == tags.size()) return null; // That actually happens!
        def lines = [];
        tags.collect{lines << toString(it);};
        return lines.join("\n");
    }

    // Separate the tag's first line (map.head) from the rest (map.tail)
    def toHeadTail = { Tag tag ->
        def headTail = tag.text().split("\n",2);
        def map = [:];
        map.head = headTail[0];
        map.tail = (headTail.size() > 1 ? "\n" + headTail[1] : "");
        return map;
    }

    private Map sessiondata; // Taglets are instantiated only once, so keep session data here
    final Map session() {return sessiondata;}
    final Map curtag() {return session().curtagdata;}

    // Taglet
    def resetSession(boolean tagarray) {
        if (tagarray) {sessiondata = [:]; }
        else {session().curtagdata = [:]; curtag().errinfo='';}
    }

    def error(errtxt) {
        def res = '<a href="http://blu.strglr.net" target="_blank"><i style="color:red">';
        res += '!!Taglet-Error(@' + getName() + curtag().errinfo+'): '+errtxt+'!!</i></a>';
    }
};

// For some reason all static field members of this module may be reset between
// register() invocations that originate in the doclet. Also, in the registration map,
// the doclet may wrap the registered taglets into its own classes (e.g. LegacyTaglet).
// Even exceptions may be altered and become uncatchable at registration time.
// Altogether, it appears that a given doclet can make the taglets -which have been
// instantiated in this module and registered at a previous step- virtually unreachable
// during a new taglet registration. Only the taglet that is registering is accessible.
// When these limitations are in place, that makes sharing data among taglets a hell.
// Attempts here to circumvent these limitations, including but not limited to,
// registering all taglets in a unique call, have lamely failed.

// The workaround here is to attach to each registered taglet a non-static table
// of all this module's taglets. At runtime when it requires another taglet, a given
// taglet can obtain the other's class, but must manage on its own to utilize it.
// (for instance it might invoke a static method or create an instance).

class GlobalReferences {
    static def tlList = [
        UpdateTaglet,
        ReferImageTaglet,
        IncludeTextTaglet
    ];

    def tlTable = [:];
    GlobalReferences() { tlList.each { tlTable[it.NAME]=it;} }
    def getTagletClass(tagname) { return tlTable[tagname]; }
    def getClassInfo(tagname) { return getTagletClass(tagname)?.tlClassInfo; }
};

class RegistrationHelper {

    // register() calls are directed to here
    static void registerTaglet(Map tlmap, reginfo) {
        // Registration of the taglet passed in the arguments
        def regtaglet = reginfo.tagletinstance; // The taglet instance to register
        def tagname = regtaglet.getName();
        regtaglet.globalrefs = new GlobalReferences();
        tlmap[tagname] = regtaglet;  // javadoc-traditional registration
        println("BlueStraggler/SlDoc (http://blu.strglr.net/Sldoc). Copyright 2011, 2012 Stellabs.");
        Log.info?.write("Taglet registration: SlDoc is now handling tag @"+tagname+"!");
    }

};


abstract class AbstractInlineTaglet extends AbstractTaglet {

    // Obvious
    final boolean isInlineTag(){true;}

    // Redirections
    final String toString(Tag[] tags){ // should not be invoked
        assert false; 
    }
    final String toString(Tag tag){
        def res = null;
        if (null != tag) try {
            resetSession(true); resetSession(false);
            res = toHtml(tag);
        } catch (TagletException te) {
            res =  "<br/>"+error(te.toString())+"<br/>";
        }
        catch(e) {
            e.printStackTrace();
            res = "<br/>"+error("unhandled exception")+"<br/>";
        }
        return res;
    }
};


abstract class AbstractBlockTaglet extends AbstractTaglet {

    // Obvious
    final boolean isInlineTag(){false;}

    // Redirections
    final String toString(Tag[] tags){ resetSession(true); return toHtml(tags); }
    final String toString(Tag tag){
        def res = null;
        if (null != tag) try {
            resetSession(false);
            def ht = toHeadTail(tag);
            def html = toHtml(ht.head) + ht.tail;
            // Rendering may vary depending on the doclet being used
            res = ["yDoc"].contains(tlp('doclet.name')) ? html+"<br/>" : "<br/>"+html;
        }
        catch (TagletException te) {
            res = "<br/>"+ error(te.toString())+"<br/>";
        }
        catch(e) {
            e.printStackTrace();
            res = "<br/>"+error("unhandled exception")+"<br/>";
        }
        return res;
    }
};


/*
 *  Taglet for tag: @sldoc.update
 */
class UpdateTaglet extends AbstractBlockTaglet {
    static final String NAME = "${tagPrefix}.update";
    static final tlClassInfo;

    public static void register(Map tlmap) {
        def inst = new UpdateTaglet();
        def reginfo = [tagletinstance:inst];
        tlRegister(tlmap, reginfo);
    }
    String toHtml(entry){
        def sDate = entry.trim();
        SimpleDateFormat sdf = new SimpleDateFormat('dd/MMM/yyyy');
        sdf.setLenient(false);
        try {
            sDate = sdf.format(sdf.parse(sDate));
        } catch(e) {
            throwerror("invalid date entry");
        }
        return "<b>Updated: </b>" + sDate;
    }
};


/*
 *  Taglet for tag: @sldoc.refer.image
 */
class ReferImageTaglet extends AbstractInlineTaglet {
    static final String NAME = "${tagPrefix}.refer.image";
    static final tlClassInfo = [statichandler:{tt,ai-> toHtml(tt,ai)}];

    public static void register(Map tlmap) {
        def inst = new ReferImageTaglet();
        def reginfo = [tagletinstance:inst];
        tlRegister(tlmap, reginfo);
    }
    static final Map defaultAttrs() {
        return [origintype:null, targettype:null, usedocroot:false]; }

    String toHtml(Tag tag){
        return toHtml(tag.text(), [tagpos:tag.position(), curtag:curtag()]);
    }

    static String toHtml(tagtext, auxinfo){
        def tagpos = auxinfo.tagpos;
        def refsource = auxinfo.refsource;
        def errsrc = {refsource?.filename};
        def html = null;

        // Tag attributes and contraints
        def attributes;
        def errinfo ='';
        def _attributes = {attributes};
        use(AttributeStuff) {
            attributes = defaultAttrs().parseAttributes(tagtext) ?: throwerror("tag parsing error",errsrc());
            errinfo = ' ..."'+(attributes._filename =~ /(?:.*\/)?([^\/]*)/)[0][1]+'"';
            if (auxinfo.curtag) auxinfo.curtag.errinfo = errinfo;
            attributes.filterDatasource();
        }

        // Origin file-data is based on the javasource, unless a reference has been passed
        def refpage;
        def origin;
        def _origin = {origin};
        FileDataHelper.with {
            refpage = javasourceData({p->tlp(p)}, tagpos);
            if (!refsource) refsource = refpage;
            origin = originData({p->tlp(p)}, refsource, attributes);
        }
        origin.filedata.with {
            type = _attributes().origintype ?: ImageConversionHelper.extension2type(extension);
            if (!type) throwerror("conversion target type unknown",errsrc());
        }

        // Target file
        def target = [:];
        def _target = {target;}
        def md5 = {
            def data = it.getBytes();
            def md = MessageDigest.getInstance("MD5"); md.update(data);
            def hash= md.digest();
            String s=""; hash.each{ s+= s.format("%02x",it); }; return s; };
        target.with {
            def troot = tlp('target.javadoc.root');
            def tpdir =  troot+'/'+(_origin().parentdata?.dirname ?: md5(origin.absparent));
            absparent = new File(tpdir).getCanonicalPath();
            parentdata = use(DirectoryStuff){ absparent.toString().dataFromPath(troot.tokenize(':')); };
        }

        target.filedata = origin.filedata.clone();
        target.filedata.with{
            if (issldoc) middlename -= '.sldoc';
            type = _attributes().targettype ?: ImageConversionHelper.type2data(type,'defto');
            extension = ImageConversionHelper.type2data(type,'ext');
            target.filename = basename+middlename+extension;
        }

        // Remove these attributes in order to not interfere with HTML tag's
        attributes.with {
            remove('origintype');
            remove('targettype');
            remove('usedocroot');
        }

        // Conversion variables
        def binding = [src:origin.with{'"'+absparent+'/'+filename+'"'},
            tgt:target.with{'"'+absparent+'/'+filename+'"'}];
        def handler = ImageConversionHelper.conversion2handler(origin.filedata.type, target.filedata.type);
        def engine = new groovy.text.GStringTemplateEngine();
        def shellCL = [];
        handler.command.tokenize(' ').each {
            shellCL << engine.createTemplate(it).make(binding).toString();
        }

        // Mkdir for target
        new File(target.absparent).with {
            mkdirs();
            if (!exists() || !isDirectory())
                throwerror("mkdir error",errsrc());
        };

        // Execute the command in a shell (because pipes etc. won't pass)
        def shellSuccess = "bash".execute().with{
            out << shellCL.join(' ');
            out << ' ;exit $?\n';
            waitFor();
            return !exitValue();
        };
        if (!shellSuccess) throwerror("conversion command failed",errsrc());
        target.with{
            file = new File(parentdata.with{rootname+'/'+dirname}+'/'+filename);
            lastmodified = file.lastModified();
        }
        if (origin.lastmodified > target.lastmodified)
            throwerror("unknown conversion failure",errsrc());

        // In the produced HTML code, regardless of its direct source,
        // the image is referred by an HTML page generated from the java-source-code

        use (DirectoryStuff) {
            attributes.src = refpage.parentdata.dirname.toString().relativize(target.parentdata.dirname)?:'.';
            attributes.src +='/'+target.filename;
        }

        // Produce HTML IMG tag
        html = "<img";
        attributes.each{k,v -> html += ' '+ k + '="'+ v + '"'};
        html += "/>";
        return html;
    }
};


/*
 *  Taglet for tag: @sldoc.include.text
 */
class IncludeTextTaglet extends AbstractInlineTaglet {
    static final String NAME = "${tagPrefix}.include.text";
    static final tlClassInfo = [statichandler:{tt,ai-> toHtml(tt,ai)}];

    public static void register(Map tlmap) {
        def inst = new IncludeTextTaglet();
        def reginfo = [tagletinstance:inst];
        tlRegister(tlmap, reginfo);
    }
    static final Map defaultAttrs() {
        return [origintype:"plaintext", usedocroot:false]; }

    String toHtml(Tag tag){
        return toHtml(tag.text(), [tagpos:tag.position(), curtag:curtag(), globalrefs:globalrefs]);
    }

    static String toHtml(tagtext, auxinfo){
        def tagpos = auxinfo.tagpos;
        def refsource = auxinfo.refsource;
        def errsrc = {refsource?.filename};
        def grefs = auxinfo.globalrefs;
        def html = null;

        // Tag attributes and contraints
        def attributes;
        def errinfo ='';
        use(AttributeStuff) {
            attributes = defaultAttrs().parseAttributes(tagtext) ?: throwerror("tag parsing error",errsrc());
            errinfo = ' ..."'+(attributes._filename =~ /(?:.*\/)?([^\/]*)/)[0][1]+'"';
            if (auxinfo.curtag) auxinfo.curtag.errinfo = errinfo;
            attributes.filterDatasource();
        }

        // Origin file-data is based on the javasource, unless a reference has been passed
        def origin; def _origin = {origin};
        FileDataHelper.with {
            if (!refsource) refsource = javasourceData({p->tlp(p)}, tagpos);
            origin = originData({p->tlp(p)}, refsource, attributes);
        }

        def htmllines = [];
        if ("plaintext" == attributes.origintype.toLowerCase()) use(HtmlStuff) {
            origin.file.eachLine{ htmllines << it.escapePlaintext(); }
            html = htmllines.join("<br/>");
        }
        else if ("plainhtml" == attributes.origintype.toLowerCase()) use(HtmlStuff) {
            origin.file.eachLine{ htmllines << it.importHtmltext(); }
            html = htmllines.join("\n");
        }
        else if ("html" == attributes.origintype.toLowerCase()) use(HtmlStuff) {
            errsrc = {origin.filename};
            origin.file.eachLine{
                def taghead = {tagPrefix};
                def linep = it.importHtmltext2(taghead());
                if ("inlinetag"==linep.type) linep.with{
                    def tagname = taghead()+tail;
                    def cloz = grefs.getClassInfo(tagname)?.statichandler ?: throwerror("unhandled @"+tagname, errsrc());
                    try {
                        _origin().haschildresource=true;
                        htmllines << before + cloz(text, [refsource:_origin(), tagpos:tagpos, globalrefs:grefs])+after;
                    } catch (TagletException te) {
                        if (!te.resource) te.resource = errsrc();
                        throw te;
                    } catch (e) {
                        e.printStackTrace();
                        throwerror("indirect unhandled exception", errsrc());
                    }
                } else {
                    htmllines << linep.text;
                }
            }
            html = htmllines.join("\n");
        }
        return html;
    }
};
